SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.2 KB · 98 lines typescript
Raw Blame History
1import { NextResponse, type NextRequest } from 'next/server';2import { createReadStream } from 'node:fs';3import { stat } from 'node:fs/promises';4import { Readable } from 'node:stream';5import { getSql } from '@rareindex/database';6import { ensureOriginal, ensureVariant, findOriginal, imageKey, nearestWidth, negativeFor, variantPath, type OriginalInfo } from '@/lib/images-core';7import { verifyImageSignature } from '@/lib/images';89export const dynamic = 'force-dynamic';10export const runtime = 'nodejs';1112const IMMUTABLE = 'public, max-age=31536000, immutable';13const NEGATIVE = 'public, max-age=3600, stale-while-revalidate=600';1415/**16 * GET /img/<sha1>.<webp|avif>?w=<width>&u=<base64url(url)>&s=<sig>17 * Serves a cached, resized copy of a third-party product image. The original URL must be18 * HMAC-signed by the server (any page that renders it) or already known in the `images` table.19 */20export async function GET(req: NextRequest, ctx: { params: Promise<{ key: string }> }) {21  const { key: rawKey } = await ctx.params;22  const m = rawKey.match(/^([a-f0-9]{40})(?:\.(webp|avif))?$/);23  if (!m) return new NextResponse('Not found', { status: 404, headers: { 'cache-control': NEGATIVE } });24  const key = m[1]!;25  const fmt = (m[2] as 'webp' | 'avif' | undefined) ?? 'webp';26  const width = nearestWidth(req.nextUrl.searchParams.get('w'));2728  // Fast path: variant already on disk.29  try {30    const vpath = variantPath(key, width, fmt);31    const s = await stat(vpath);32    return fileResponse(vpath, s.size, `image/${fmt}`, req);33  } catch {34    /* build below */35  }3637  // Resolve the source URL: signed param first, then DB lookup by cache key / sha1(url).38  let url: string | null = null;39  const u = req.nextUrl.searchParams.get('u');40  const s = req.nextUrl.searchParams.get('s');41  if (u && s) {42    try {43      const decoded = Buffer.from(u, 'base64url').toString('utf8');44      if (imageKey(decoded) === key && verifyImageSignature(decoded, s)) url = decoded;45    } catch {46      url = null;47    }48  }49  if (!url) {50    try {51      const sql = getSql();52      const rows = (await sql`select url from images where cache_key = ${key} limit 1`) as Array<{ url: string }>;53      url = rows[0]?.url ?? null;54    } catch {55      url = null;56    }57  }5859  let info: OriginalInfo | null = await findOriginal(key);60  if (!info) {61    if (!url) return new NextResponse('Unknown image', { status: 404, headers: { 'cache-control': NEGATIVE } });62    if (negativeFor(url)) return new NextResponse('Image unavailable', { status: 404, headers: { 'cache-control': NEGATIVE, 'x-ri-image': 'negative-cache' } });63    const out = await ensureOriginal(url);64    if (!out.ok) {65      void recordFailure(key, url, out.status, out.reason);66      return new NextResponse('Image unavailable', { status: 404, headers: { 'cache-control': NEGATIVE, 'x-ri-image': out.status } });67    }68    info = out.info;69  }70  try {71    const vpath = await ensureVariant(info, width, fmt);72    const st = await stat(vpath);73    return fileResponse(vpath, st.size, `image/${fmt}`, req);74  } catch (err) {75    console.error('[img] variant failed', key, err instanceof Error ? err.message : err);76    return new NextResponse('Image processing failed', { status: 500, headers: { 'cache-control': 'no-store' } });77  }78}7980function fileResponse(filePath: string, size: number, contentType: string, req: NextRequest): NextResponse {81  const etag = `"${size}-${filePath.slice(-24).replace(/[^a-z0-9]/gi, '')}"`;82  if (req.headers.get('if-none-match') === etag) return new NextResponse(null, { status: 304, headers: { etag, 'cache-control': IMMUTABLE } });83  const stream = Readable.toWeb(createReadStream(filePath)) as unknown as ReadableStream;84  return new NextResponse(stream, {85    status: 200,86    headers: { 'content-type': contentType, 'content-length': String(size), 'cache-control': IMMUTABLE, etag, 'x-content-type-options': 'nosniff', 'accept-ch': 'DPR, Width' },87  });88}8990async function recordFailure(key: string, url: string, status: string, reason: string): Promise<void> {91  try {92    const sql = getSql();93    await sql`update images set status = ${status}, error = ${reason.slice(0, 200)}, checked_at = now(), cache_key = ${key} where url = ${url}`;94  } catch {95    /* best effort */96  }97}98